[Phoenix] Socket Server

前言

最近在用 Elixir 重寫阿瓦隆的伺服器,簡單記錄利用使用 Phoenix 這個框架使用 Socket 的心得。

新建

安裝好 ElixirPhoenix 後先新增一個專案,在這邊先不用到資料庫因此先不安裝 Ecto

1
mix phoenix.new avalon_backend --no-ecto

遊戲大廳及使用者

avalon_backend.ex 新增一個 worker 用來開啟 GenServer

1
2
3
4
5
# lib/avalon_backend.ex
children = [
supervisor(AvalonBackend.Endpoint, []),
worker(AvalonBackend.UserModel, [%{}])
]

讓伺服器監聽 game:lobby 這個 channel 且讓 socket 在連接的時候給予 id 以便於之後的使用。

1
2
3
4
5
6
7
8
9
# web/channels/user_socket.ex
channel "game:lobby", AvalonBackend.LobbyChannel
def connect(_params, socket) do
id = Enum.random(0..1000)
user = %{ :id => id }
socket = assign(socket, :user, user)
{:ok, socket}
end

lobby_channel.ex 定義 channel 在特定事件中所會觸發的事件。

使用者加入會將使用者保存起來,若離開則會移除該使用者,並將當前在 game:lobby 頻道的使用者 broadcast 給該頻道的所有人。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# web/channels/lobby_channel.ex
defmodule AvalonBackend.LobbyChannel do
use AvalonBackend.Web, :channel
alias AvalonBackend.UserModel
def join("game:lobby", _payload, socket) do
user = socket.assigns.user
users = UserModel.user_joined("game:lobby", user)["game:lobby"]
send self(), {:after_join, users}
{:ok, socket}
end
def terminate(_reason, socket) do
user_id = socket.assigns.user.id
users = UserModel.user_left("game:lobby", user_id)["game:lobby"]
lobby_update(socket, users)
:ok
end
def handle_info({:after_join, users}, socket) do
lobby_update(socket, users)
{:noreply, socket}
end
defp lobby_update(socket, users) do
broadcast! socket, "lobby_update", %{ users: get_users_id(users) }
end
defp get_users_id(nil), do: []
defp get_users_id(users) do
Enum.map users, &(&1.id)
end
end

由於 Elixir 沒有全域變數,在儲存變數的需求下我們必須透過 GenServer , 在這邊利用 Map 型態來保存所有使用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# web/models/user_model.ex
defmodule AvalonBackend.UserModel do
use GenServer
def start_link(initial_state) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
def user_joined(channel, user) do
GenServer.call(__MODULE__, {:user_joined, channel, user})
end
def user_left(channel, user_id) do
GenServer.call(__MODULE__, {:user_left, channel, user_id})
end
# GenServer implementation
def handle_call({:user_joined, channel, user}, _from, state) do
new_state = case Map.get(state, channel) do
nil ->
Map.put(state, channel, [user])
users ->
Map.put(state, channel, Enum.uniq([user | users]))
end
{:reply, new_state, new_state}
end
def handle_call({:user_left, channel, user_id}, _from, state) do
new_users = state
|> Map.get(channel)
|> Enum.reject(&(&1.id == user_id))
new_state = Map.update!(state, channel, fn(_) -> new_users end)
{:reply, new_state, new_state}
end
end

在 client 端引入自己攥寫的 socket.js

1
2
// web/static/js/app.js
import socket from "./socket"

首先引入來自 PhoenixSocket ,讓該 socket 連接 game:lobby ,並監聽 lobby_update 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// web/static/js/socket.js
import {Socket} from "phoenix"
let socket = new Socket("/socket")
socket.connect()
// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("game:lobby", {})
channel.on('lobby_update', function(resp) {
console.log(resp);
});
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
export default socket

傳送訊息給特定使用者

透過將不同使用者加入到自己獨立的 channel ,透過 broadcast 該頻道的方式來對該使用者發出事件。

在 Server 端產生完 id 後,回傳 id 給 client 端。

1
2
3
4
5
6
7
# web/channel/lobby_channel.ex
def join("game:lobby", _payload, socket) do
user = socket.assigns.user
users = UserModel.user_joined("game:lobby", user)["game:lobby"]
send self(), {:after_join, users}
{:ok, %{ id: user.id }, socket}
end

當 client 端接受 id 後則連接該 id 的頻道 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// web/static/js/socket.js
import {Socket} from "phoenix"
let socket = new Socket("/socket")
socket.connect()
let channel = socket.channel("game:lobby", {})
let userChannel;
channel.on('lobby_update', function(response) {
console.log(response);
});
channel.join()
.receive("ok", resp => {
console.log("Joined successfully", resp)
userChannel = socket.channel("user:" + resp.id);
userChannel.on("message", msg => console.log(msg) )
userChannel.join()
.receive("ok", resp => console.log("joined private user channel") )
.receive("error", err => console.log(err));
})
.receive("error", resp => { console.log("Unable to join", resp) })
export default socket

新增使用者專屬的 channel ,並當接受 message 事件時,發送訊息到該指定 user 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# web/channels/user_socket.ex
channel "user:*", AvalonBackend.UserChannel
# web/channels/user_channel.ex
defmodule AvalonBackend.UserChannel do
use AvalonBackend.Web, :channel
def join("user:" <> _id, _payload, socket) do
{:ok, socket}
end
def handle_in("message", %{"id" => id, "message" => message }, socket) do
AvalonBackend.Endpoint.broadcast "user:" <> id, "message", %{ message: message }
{:noreply, socket}
end
end

在 client 新增輸入欄讓使用者可以輸入 id 及 message 來發送。

1
2
3
4
<!-- web/templates/layout/app.html.eex -->
<input id="idInput" placeholder="id"></input>
<input id="messageInput" placeholder="message"></input>
<button id="submitButton">Submit</button>

1
2
3
4
5
6
7
8
9
10
// web/static/js/socket.js
document.getElementById('submitButton').addEventListener('click', () => {
let args = {
id : document.getElementById('idInput').value,
message : document.getElementById('messageInput').value
}
userChannel.push('message', args)
.receive('ok', () => console.log('success'))
.receive('error', (e) => console.log(e));
})

如此一來基本的 Server 功能就完成了。

GitHub

avalon-ng/avalon_backend

參考

Creating a Game Lobby System in Phoenix with Websockets